앱 생명주기 변화에 안전하게 대응하기

앱 생명주기 변화에 안전하게 대응하기

한눈에 보기

앱 lifecycle 알림은 “종료 직전에 반드시 호출되는 저장 버튼”이 아니다. 운영체제는 callback 없이 프로세스를 끝낼 수 있고 일부 상태 전이를 건너뛸 수도 있다. 중요한 데이터는 변경 시점에 영속화하고, lifecycle에는 보이지 않을 때 중단할 작업다시 보일 때 재검증할 상태를 연결한다.

모바일 앱은 사용자가 명시적으로 종료하지 않아도 foreground를 떠난다. 전화가 오거나 생체 인증 창이 뜨고, 앱 전환 화면에 들어가며, 다른 앱을 사용하다 몇 시간 뒤 돌아올 수 있다.

void didChangeAppLifecycleState(
  AppLifecycleState state,
) {
  if (state == AppLifecycleState.resumed) {
    refreshSession();
  }
}

이 코드만으로는 부족하다. inactive가 곧 background인지, paused에서 모든 timer가 정지하는지, resume마다 전체 API를 다시 불러와야 하는지에 대한 정책이 없다.

앱 lifecycle 대응은 callback 목록을 외우는 일이 아니라 다음 세 질문에 답하는 일이다.

  1. 사용자가 앱을 보고 있지 않을 때 계속할 작업은 무엇인가?
  2. 돌아왔을 때 오래되었을 가능성이 있는 상태는 무엇인가?
  3. callback을 받지 못해도 보존되어야 할 데이터는 무엇인가?

목차

Widget lifecycle과 App lifecycle은 다르다

State.disposeAppLifecycleState.paused를 같은 것으로 생각하기 쉽다.

Widget lifecycle은 특정 Element가 Widget tree에 존재하는지를 다룬다.

createState → initState → build → deactivate → dispose

App lifecycle은 Flutter application이 host view와 어떤 관계에 있고 사용자에게 보이는지를 다룬다.

detached ↔ resumed ↔ inactive ↔ hidden ↔ paused

앱 전체가 background로 이동해도 현재 route의 State는 dispose되지 않고 그대로 남을 수 있다. 반대로 앱이 계속 resumed 상태여도 조건부 렌더링이나 navigation 때문에 특정 Widget은 dispose될 수 있다.

상황 Widget State App lifecycle
다른 route로 이동 이전 화면 dispose 가능 resumed 유지 가능
홈 버튼으로 앱 떠남 Widget tree 유지 가능 inactive→hidden→paused 가능
split view에서 focus 잃음 Widget 유지 inactive 가능
앱 프로세스 강제 종료 dispose 보장 없음 종료 알림 보장 없음

따라서 controller와 subscription의 Widget 소유권은 StatefulWidget의 상태가 State 객체에 있는 이유처럼 dispose에서 정리하고, 앱 가시성에 따라 잠시 중단할 resource는 App lifecycle에서 pause/resume한다. 두 처리가 모두 필요할 수 있다.

AppLifecycleState를 의미로 이해하기

Flutter는 여러 플랫폼을 공통 상태 모델로 표현한다. 플랫폼에 없는 상태는 일관된 상태 machine을 위해 합성될 수 있다.

stateDiagram-v2
    [*] --> detached
    detached --> resumed
    resumed --> inactive
    inactive --> resumed
    inactive --> hidden
    hidden --> inactive
    hidden --> paused
    paused --> hidden
    paused --> detached

각 상태를 단순화하면 다음과 같다.

resumed

앱이 보이고 입력 focus를 가진 정상 실행 상태다. Android에서는 Activity lifecycle 이름과 완전히 일대일로 대응하지 않으며 window focus도 고려된다.

inactive

적어도 하나의 view가 보이지만 입력 focus가 없을 수 있다. 알림 shade, 앱 전환 화면, 전화, 시스템 dialog, split screen 등에서도 나타날 수 있다. 곧 hidden이나 paused로 갈 수 있다고 가정해야 하지만 이미 완전히 background라고 단정해서는 안 된다.

hidden

모든 view가 보이지 않는 상태다. 모바일에서는 inactive와 paused 사이에 공통 상태 모델을 위해 합성될 수 있고, desktop minimize나 web hidden tab에도 해당할 수 있다.

paused

앱이 보이지 않고 사용자 입력에 응답하지 않는 모바일 상태다. 이 상태에서는 engine이 frame callback을 호출하지 않는다. desktop과 web에서는 이 상태가 호출되지 않는다.

detached

Flutter engine이 host view에서 분리된 상태다. 초기 상태일 수도 있고 지원 플랫폼에서 모든 view가 detach된 뒤 나타날 수도 있다. “이 callback 뒤에는 안전하게 저장할 시간이 있다”는 종료 훅으로 보아서는 안 된다.

Android·iOS lifecycle 이름과 직접 대입하지 않는다

Flutter 상태는 플랫폼 차이를 정규화한 모델이다. camera, background location처럼 native lifecycle에 민감한 기능은 해당 plugin과 플랫폼 문서를 함께 확인한다.

모든 상태 전이를 받는다고 가정하지 않는다

운영체제는 다음 상황에서 앱에 알리지 않고 프로세스를 끝낼 수 있다.

따라서 다음 설계는 안전하지 않다.

void onDetach() {
  saveAllUnsavedDocuments();
}

detach callback이 오지 않으면 편집 내용이 사라진다. 중요한 데이터의 durability가 lifecycle 알림에 의존한다.

더 안전한 원칙은 다음과 같다.

Lifecycle callback은 최적화와 정합성 재검증 신호이지 유일한 저장 기회가 아니다.

AppLifecycleListener로 관찰하기

현대 Flutter에서는 AppLifecycleListener로 상태 변화를 관찰할 수 있다.

class AppVisibilityController {
  AppVisibilityController({
    required this.session,
    required this.sync,
  });

  final SessionManager session;
  final SyncCoordinator sync;

  late final AppLifecycleListener _listener;
  bool _initialized = false;

  void initialize() {
    if (_initialized) return;
    _initialized = true;

    _listener = AppLifecycleListener(
      onStateChange: _onStateChange,
    );
  }

  void _onStateChange(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        unawaited(_onResumed());
      case AppLifecycleState.inactive:
        sync.prepareForPossibleBackground();
      case AppLifecycleState.hidden:
        sync.pauseVisibleOnlyWork();
      case AppLifecycleState.paused:
        sync.markBackgrounded();
      case AppLifecycleState.detached:
        sync.releaseViewBoundResources();
    }
  }

  Future<void> _onResumed() async {
    await session.revalidateIfNeeded();
    await sync.resumeAndRefreshIfStale();
  }

  void dispose() {
    if (!_initialized) return;
    _listener.dispose();
  }
}

예시는 정책 분리를 설명하기 위한 코드다. switch case의 종료 방식과 unawaited import는 실제 Dart 버전과 lint 설정에 맞춰 확인한다.

개별 callback을 사용할 수도 있다.

_listener = AppLifecycleListener(
  onResume: _handleResume,
  onHide: _handleHidden,
  onPause: _handlePaused,
);

기존 코드에서는 WidgetsBindingObserver를 mixin하거나 구현해 관찰한다.

class _PageState extends State<Page>
    with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(
    AppLifecycleState state,
  ) {
    // 상태에 따른 정책 실행
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
}

앱 전체 정책을 page State마다 등록하면 동일 refresh가 중복 실행될 수 있다. root에 하나의 coordinator를 두고 feature별 정책으로 전달하는 편이 예측하기 쉽다. 특정 camera page처럼 화면과 resource 수명이 같다면 해당 State가 직접 관찰할 수 있다.

숨겨질 때 멈출 작업과 유지할 작업

background 전환 때 모든 작업을 일괄 취소하면 필요한 업로드나 저장까지 끊을 수 있다. 작업을 목적별로 분류한다.

작업 hidden/paused 정책 예 이유
UI animation 중단 화면에 보이지 않고 frame도 제한됨
화면용 polling 중단 데이터·배터리 낭비
websocket presence offline 전환 또는 종료 사용자 가시성 정책
작성 중 draft 먼저 로컬 저장 데이터 유실 방지
사용자 시작 업로드 플랫폼 정책에 따라 계속/백그라운드 전송 완료 기대가 있음
음악 재생 제품 기능에 따라 유지 background audio 권한 필요
위치 추적 명시적 동의와 platform 설정 민감 권한·배터리
결제 진행 서버 상태로 복구 가능하게 설계 앱 중단에도 결과 발생 가능

“앱이 background면 네트워크를 전부 끈다”가 아니라 작업마다 사용자 기대와 플랫폼 제한을 정한다.

Timer를 멈추는 예시는 다음과 같다.

class VisiblePolling {
  Timer? _timer;

  void resume() {
    if (_timer != null) return;

    _timer = Timer.periodic(
      const Duration(seconds: 30),
      (_) => unawaited(refresh()),
    );
  }

  void pause() {
    _timer?.cancel();
    _timer = null;
  }

  Future<void> refresh() async {
    // 화면에 필요한 데이터 재검증
  }
}

resume이 여러 번 호출돼도 timer가 중복 생성되지 않게 idempotent하게 만든다.

resumed에서 다시 검증할 것

앱이 돌아왔을 때 background 이전의 메모리 state가 여전히 최신이라는 보장은 없다.

세션

데이터

기기 환경

모든 resume마다 전체 데이터를 무조건 다시 받으면 서버와 사용자 경험에 부담을 준다. 마지막 foreground 시각과 데이터별 stale time을 사용한다.

class ResumePolicy {
  ResumePolicy(this.clock);

  final Clock clock;
  DateTime? _backgroundedAt;

  void markBackgrounded() {
    _backgroundedAt = clock.now();
  }

  bool shouldRefresh({
    Duration threshold = const Duration(minutes: 2),
  }) {
    final leftAt = _backgroundedAt;
    if (leftAt == null) return true;

    return clock.now().difference(leftAt) >= threshold;
  }
}

threshold는 예시 값이다. 주식 가격과 사용자 프로필은 freshness 요구가 다르므로 feature별로 정한다.

flowchart TD
    R["resumed"] --> S{"세션 만료 가능?"}
    S -- "예" --> V["세션 재검증"]
    S -- "아니오" --> D{"데이터 stale?"}
    V --> D
    D -- "예" --> F["필요한 query만 refresh"]
    D -- "아니오" --> C["기존 state 유지"]

중요한 데이터는 즉시 영속화한다

메모리에만 있는 draft를 paused callback에서 저장하면 callback 누락 시 데이터가 사라진다.

다음과 같이 변경 직후 짧은 debounce로 로컬 저장할 수 있다.

class DraftAutosaver {
  DraftAutosaver(this.storage);

  final DraftStorage storage;
  Timer? _timer;
  Draft? _pending;

  void schedule(Draft draft) {
    _pending = draft;
    _timer?.cancel();
    _timer = Timer(
      const Duration(milliseconds: 500),
      _flush,
    );
  }

  Future<void> _flush() async {
    final draft = _pending;
    if (draft == null) return;

    await storage.write(draft);

    if (identical(_pending, draft)) {
      _pending = null;
    }
  }

  Future<void> flushNow() async {
    _timer?.cancel();
    _timer = null;
    await _flush();
  }

  void dispose() {
    _timer?.cancel();
  }
}

hidden 알림에서는 flushNow를 best-effort로 호출할 수 있지만 완료할 시간을 보장받는다고 가정하지 않는다.

저장 정책을 데이터 중요도에 맞춘다.

이벤트 빈도 제어는 Debounce와 Throttle을 선택하는 기준과 연결된다.

세션과 네트워크 재연결 정책

background에서 socket 연결이 끊기거나 NAT mapping이 만료될 수 있다. resumed에서 기존 객체가 “connected”라고 표시돼도 실제 통신 가능 여부를 확인해야 한다.

class ConnectionCoordinator {
  Future<void>? _resumeFuture;

  Future<void> resume() {
    return _resumeFuture ??= _resumeOnce().whenComplete(() {
      _resumeFuture = null;
    });
  }

  Future<void> _resumeOnce() async {
    final session = await auth.revalidate();
    if (!session.isValid) {
      connection.disconnect();
      return;
    }

    await connection.reconnectIfNeeded();
    await outbox.flush();
  }
}

동시에 여러 feature가 resume event를 받더라도 같은 세션 refresh를 합칠 수 있다.

실패는 정상 상태로 다룬다.

Lifecycle coordinator가 Navigator.of(context)를 직접 호출하기보다 인증 상태를 갱신하고 선언형 router가 route를 계산하도록 하면 수명 문제가 줄어든다. Flutter Navigator와 선언형 라우팅 비교와 이어지는 지점이다.

타이머와 경과 시간을 다루는 방법

앱이 background에 있는 동안 periodic timer가 정확히 실행될 것이라 기대하면 안 된다. 특히 paused 상태에서는 frame callback이 호출되지 않고 운영체제가 실행을 제한한다.

Countdown을 timer tick 횟수로 계산하면 복귀 후 시간이 틀릴 수 있다.

int secondsLeft = 60;

Timer.periodic(const Duration(seconds: 1), (_) {
  secondsLeft -= 1;
});

마감 시각을 저장하고 현재 시각과 차이를 계산한다.

class CountdownModel {
  CountdownModel({
    required this.endsAt,
    required this.clock,
  });

  final DateTime endsAt;
  final Clock clock;

  Duration get remaining {
    final value = endsAt.difference(clock.now());
    return value.isNegative ? Duration.zero : value;
  }
}

UI가 보일 때 timer는 단지 다시 그릴 신호다. 진실의 원천은 tick count가 아니라 endsAt이다.

void onResume() {
  setState(() {
    remaining = model.remaining;
  });
  ticker.start();
}

서버 기준 만료가 중요한 경우 device clock 변경도 고려하고 서버 시각 offset이나 만료 token claim을 사용한다.

카메라·오디오 같은 플랫폼 리소스

Camera, microphone, video player, WebView 같은 resource는 단순 Dart timer보다 플랫폼 lifecycle 영향이 크다.

카메라 화면의 정책 예시는 다음과 같다.

void onLifecycleChanged(AppLifecycleState state) {
  switch (state) {
    case AppLifecycleState.resumed:
      unawaited(_initializeCameraIfNeeded());
    case AppLifecycleState.inactive:
    case AppLifecycleState.hidden:
    case AppLifecycleState.paused:
      unawaited(_releaseCamera());
    case AppLifecycleState.detached:
      unawaited(_releaseCamera());
  }
}

실제 camera plugin의 권장 lifecycle 처리와 API를 따라야 한다. 초기화와 해제가 겹치지 않도록 mutex나 generation을 두고, 권한이 background 동안 철회될 수 있으므로 resume 실패를 처리한다.

오디오 앱은 background에서도 재생을 유지할 수 있지만 iOS background mode, Android foreground service, media notification 같은 플랫폼 설정이 필요하다. lifecycle callback만으로 background 실행 권한이 생기지 않는다.

위치 추적과 건강 데이터는 기술적 가능성뿐 아니라 명시적 사용자 동의, 최소 수집, 개인정보 정책을 함께 검토한다.

플러그인 계약을 확인한다

Flutter 공통 lifecycle event와 native resource의 실제 사용 가능 시점은 같지 않을 수 있다. 사용 중인 plugin 버전과 Android/iOS 문서를 기준으로 테스트한다.

중복 refresh와 race condition 막기

inactive와 resumed가 짧은 시간에 반복되거나 여러 화면 observer가 같은 작업을 시작하면 요청이 중복될 수 있다.

resume A → session refresh 시작
resume B → session refresh 시작
response B → 최신 token 저장
response A → 이전 token으로 덮어씀

single-flight로 같은 종류의 진행 요청을 공유한다.

class RefreshGate {
  Future<Session>? _inFlight;

  Future<Session> run(
    Future<Session> Function() operation,
  ) {
    return _inFlight ??= operation().whenComplete(() {
      _inFlight = null;
    });
  }
}

resumed handler 자체도 순서를 보장해야 한다.

int _generation = 0;

Future<void> handleResume() async {
  final generation = ++_generation;
  final snapshot = await loadFreshSnapshot();

  if (generation != _generation) return;
  state.replace(snapshot);
}

background로 다시 전환되면 generation을 증가시켜 진행 중 결과를 visible-only state에 적용하지 않게 할 수 있다.

다음 정책을 feature별로 정한다.

상황 정책 예
동일 session refresh 중복 하나의 Future 공유
새 검색이 이전 검색 대체 이전 취소 또는 latest wins
upload 진행 중 background background transfer로 handoff
resume 중 다시 hidden UI 반영 취소, cache 저장은 허용
offline resume 즉시 반복 대신 connectivity + backoff

테스트와 관측

Lifecycle 버그는 emulator의 한 번 홈 버튼 테스트로 충분하지 않다.

상태 전이 단위 테스트

Coordinator가 Flutter callback 자체보다 의미 있는 method를 받게 만들면 독립적으로 테스트할 수 있다.

test('오래 background에 있으면 resume 때 refresh한다', () async {
  final clock = FakeClock();
  final coordinator = LifecycleCoordinator(
    clock: clock,
    repository: fakeRepository,
  );

  coordinator.onHidden();
  clock.elapse(const Duration(minutes: 10));
  await coordinator.onResumed();

  expect(fakeRepository.refreshCount, 1);
});

실제 기기 시나리오

관측 event

원시 lifecycle 로그를 무제한 남기기보다 문제 진단에 필요한 전이를 구조화한다.

{
  "event": "app_lifecycle_changed",
  "from": "hidden",
  "to": "resumed",
  "background_duration_ms": 482000,
  "refresh_policy": "stale_only"
}

사용자 ID, token, 화면 입력 내용을 lifecycle 로그에 넣지 않는다.

refresh latency, 중복 refresh 수, reconnect 성공률, autosave 실패를 함께 관측하면 정책이 실제로 작동하는지 알 수 있다.

실무 체크리스트

상태 해석

숨김과 복귀

데이터와 안정성

마무리

앱 lifecycle은 운영체제가 제공하는 가시성·focus·host view 상태의 신호다. Flutter는 플랫폼 차이를 resumed, inactive, hidden, paused, detached로 정규화하지만 모든 상태가 모든 플랫폼에서 같은 방식으로 발생하는 것은 아니다.

숨겨질 때는 화면에만 필요한 animation과 polling을 중단하고, 사용자 시작 작업은 별도 background 정책에 따라 처리한다. resumed에서는 세션, 데이터 freshness, 권한과 연결을 재검증하되 stale time과 single-flight로 중복 작업을 줄인다. Countdown처럼 background 시간에 영향을 받는 값은 tick 횟수가 아니라 절대 시각을 진실의 원천으로 둔다.

가장 중요한 원칙은 종료 알림을 저장 보장으로 사용하지 않는 것이다. 운영체제는 callback 없이 앱을 끝낼 수 있다. 중요한 데이터는 변경 과정에서 이미 안전하게 저장하고 lifecycle callback은 flush와 재검증을 위한 추가 신호로 사용한다.

안전한 lifecycle 대응은 모든 callback에서 무언가를 실행하는 것이 아니라, 앱이 보이지 않아도 보존할 상태와 중단할 작업, 돌아왔을 때 의심해야 할 상태를 명시하는 일이다.

관련 노트

참고 자료